fix(billing): bill browser sessions per visit, not per tab - #360
Open
Makisuo wants to merge 2 commits into
Open
Conversation
The billed unit was one session-metadata row with `version == 1`. But `version` exists so `argMax(field, Version)` resolves the newest row of a ReplacingMergeTree — it was never designed to be a meter, and using it as one over-charged in four separate ways. **Per-tab, per-origin scope.** The session record lives in sessionStorage, scoped to one tab *and* one origin, so four open tabs were four charges and maple.dev -> app.maple.dev was two. SessionId stays per-tab (the merge invariant on `(OrgId, SessionId)` depends on it); a new `claimVisit()` claims one billable visit per visitor per 30-minute idle window against the cookie the visitor id already rides, so tabs and subdomains collapse into one charge. The gateway now meters `billable_start == 1 && version == 1`: the flag is sticky across a session's rows so the row surviving the merge still records the charge, and `version` keeps the charge singular. **Sampling didn't reduce billing.** `replay.sampleRate` gated only the rrweb chunk, so an org on 0.1 paid for 100% of its sessions — the one lever named "sample" moved only the part we don't charge for. One draw now feeds `captureSession` (metadata rows + distilled event sink) and `recordReplay`, a strict subset. `replayEnabled: false` deliberately still captures: turning off video is not turning off analytics. **Quota exhaustion billed once a minute.** `readRecord` preferred sessionStorage whenever `getItem` succeeded while `writeRecord` swallowed `setItem` failures, so on quota exhaustion `metaVersion` pinned at 1 and every 60s heartbeat re-billed. A `writesFailed` latch hands control to the in-memory record, and clears when writes recover. **The spend chart wasn't even showing the bill.** `dailySessionCountQuery` used `count()` with no FINAL against a ReplacingMergeTree, counting every unmerged heartbeat row — a 10-minute session rendered as ~12 and moved between refreshes. Now `uniq(SessionId)`. Adds `session_replays.BillableStart` (migration 0013) so the invoice is reproducible from the warehouse for the first time, and a `ingest_billed_browser_sessions_total` counter split by whether the visitor id persisted, to size the one tail this model can't fix: storage-blocked browsers can't hold a claim, so they still re-bill per page load. SCHEMA_VERSION bumps to 13 — BYO-ClickHouse orgs need 0013 applied before they are routed direct ingest again.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
browser_sessionswas billing higher than real traffic justified, and the number in Settings didn't agree with what Autumn charged.The root cause: the billed unit was one session-metadata row with
version == 1. Butversionexists soargMax(field, Version)resolves the newest row of a ReplacingMergeTree — it was never designed to be a meter, and there is no server-side dedup at all. Billing was entirely delegated to a client-side counter written for a different job.Four separate over-counts fell out of that.
What changed
1. Per-tab, per-origin scope → per visit
The session record lives in
sessionStorage, which the browser scopes to one tab and one origin. Four open tabs were four charges;maple.dev→app.maple.devwas two, for one person doing one thing.SessionIdstays per-tab — the merge invariant on(OrgId, SessionId)depends on it, and two origins sharing an id would overwrite rather than merge. Instead, a newclaimVisit()(packages/browser-session/src/visit.ts) claims one billable visit per visitor per 30-minute idle window, against the cookie + localStorage pair the visitor id already rides — the one store in the SDK that spans tabs and subdomains.The gateway now meters
billable_start == 1 && version == 1. Both halves are load-bearing:billable_startis sticky across every row of a billable session, so the row that survives the ReplacingMergeTree merge still records that the session was charged.version == 1is what stops those repeats from re-billing.The 30-minute claim window is the exact
IDLE_TIMEOUT_MSthe session uses (now exported rather than duplicated) — if they drifted, a rotation would either go uncharged or charge twice.2.
replay.sampleRatenow reduces the billIt gated only the rrweb chunk, so an org on
sampleRate: 0.1was billed for 100% of its sessions — the one lever named "sample" moved only the part we don't charge for.One draw now feeds two decisions:
captureSession(metadata rows and the distilled event sink) andrecordReplay, a strict subset. Both had to move together —session_eventsrows without a parentsession_replaysrow are orphans the session UI can't render.replayEnabled: falsedeliberately still captures: turning off video is not a request to turn off analytics.Default is
sampleRate: 1, so default behaviour is unchanged; only orgs that explicitly sampled down are affected, in the direction they asked for.3. Quota exhaustion billed once a minute
readRecordpreferredsessionStoragewhenevergetItemsucceeded, whilewriteRecordswallowedsetItemfailures. On quota exhaustion — reads work, writes don't —metaVersionpinned at 1, so every 60s heartbeat re-postedversion: 1and was billed. One 30-minute session, 30 charges.A
writesFailedlatch now hands control to the in-memory record, and clears when writes recover (a quota freed by another tab closing should put the durable store back in charge).4. The spend chart wasn't showing the bill
dailySessionCountQueryusedCH.count()with noFINALagainst a ReplacingMergeTree, counting every unmerged heartbeat row — a 10-minute session rendered as ~12, and the number moved between refreshes. Its doc comment claimed the series "can't drift" from the cycle total; it was the only session query in the repo that hadn't adopteduniq(SessionId).Reviewer notes
The gateway keeps a fallback, and it is not defensive. A row with no
billable_startbills under the oldversion == 1rule. Customers pin SDK versions, so bundles predating the field keep posting for months — reading their silence as "not billable" would silently stop billing those orgs entirely. Remove it only once the oldest SDK in the wild emits the field.SCHEMA_VERSIONbumps to 13. BYO-ClickHouse orgs need migration 0013 applied before they're routed direct ingest again. TheALTERis a trailing column with a constant DEFAULT, so it's metadata-only on ClickHouse and Tinybird alike — noMATERIALIZE COLUMN.The chart is deliberately not on
countIf(BillableStart = 1)yet, though that's what would reproduce the invoice exactly.BillableStartdefaults to 0, and rows from pinned older SDKs are billed by the legacy fallback while reading as 0 — switching now would under-report for those customers, which is a worse lie than over-reporting. The mixed window isn't distinguishable in the warehouse: the surviving row after a merge is the last one posted, and legacy sessions never stamped the flag on it. Reasoning is in the doc comment; switch once the field is broadly deployed.Cookie plumbing moved to
packages/browser-session/src/cookie.ts, shared byvisitor.tsandvisit.ts. The visit claim needs the sameDomain=the visitor id uses — a claim written host-only while the visitor id spans subdomains would stop deduplicating exactly where it matters most, on the marketing-site → app hop.configureVisitorCookiekeeps its name and export site so no caller changes.One tail this does not fix: storage-blocked browsers (Safari ITP, incognito, cookie-blocking extensions) can't hold a claim, so they still re-bill per page load. Not fixable client-side. A new
ingest_billed_browser_sessions_totalcounter splits billed sessions byvisitor_persistedso the size of that tail is a number before anyone prices around it.Verification
bun typecheck— 36/36 packages@maple/browser-sessiontests, incl. multi-tab and multi-subdomain claim refusal, the quota-exhaustionmetaVersionregression,billable_startstickiness across heartbeat/unload rows, and the wire key pinned inmeta-row.test.ts@maple/browsertests, incl.sampleRate: 0posting nothing (no meta row, no events) andenabled: false, sampleRate: 1still capturingbilling-usagequery tests, 55 domain ClickHouse tests, 72 Rust ingest tests (incl. the new meter predicate and its legacy fallback)Not run: the end-to-end check — 3 tabs plus a second subdomain resolving to 1 billed session in Settings — needs Postgres, the ingest gateway, and a live Autumn customer. Each seam is unit-tested, including the
billable_startkey across SDK → Rust → warehouse, but that is not the same as watching the number.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.